Synthetic Control: When You Have No Control Group

causal inference
econometrics
synthetic control
difference-in-differences
R
From the operational dilemma to SDID, a practitioner’s guide to causal inference with aggregate units. With full R implementation.
Published

September 7, 2026

Introduction

Consider a setting common throughout applied econometrics: a single unit receives treatment at a known date, and a panel of untreated units is observed over the same period. The canonical example is Abadie, Diamond and Hainmueller’s (2010) study of California’s Proposition 99, but the structure recurs across policy evaluation, comparative politics, and industrial organization. One treated unit. A donor pool of untreated comparators. A pre-treatment window in which no unit is treated, and a post-treatment window in which only one is.

This structure poses a version of what Holland (1986) calls the fundamental problem of causal inference. The quantity of interest, the outcome the treated unit would have exhibited absent treatment, is never observed. It must be constructed from the data available on untreated units. The question that follows is not whether this construction is necessary but how it should be done, and this is where the standard toolkit of applied microeconometrics runs into difficulty.

Difference-in-differences is the default choice, and for good reason: it is transparent, well understood, and requires only two periods and two groups in its simplest form. Its identifying assumption, however, is that the treated and control units would have followed parallel trends absent treatment. With a single treated unit and a heterogeneous donor pool, this assumption is often untestable in any meaningful sense and, when pre-treatment trends are inspected, frequently rejected outright. A treated unit with a distinct pre-existing trajectory has no single comparator, and no unweighted average of comparators, that can serve as a credible counterfactual. Matching on the average of pre-treatment levels is not the same as matching on pre-treatment dynamics, and it is the dynamics that the parallel trends assumption requires.

The synthetic control method, developed by Abadie and Gardeazabal (2003) and formalized by Abadie, Diamond and Hainmueller (2010), addresses this gap directly. Rather than selecting a single comparison unit or averaging across a donor pool, it constructs a weighted combination of donor units chosen to reproduce the treated unit’s pre-treatment trajectory as closely as possible, under the constraint that weights are non-negative and sum to one. This constraint restricts the synthetic unit to the convex hull of the donor pool, so it never rests on extrapolation beyond a combination of observed units. Where difference-in-differences imposes structural assumptions and hopes they hold, the synthetic control method makes the quality of the counterfactual visible and inspectable, in the form of the pre-treatment fit itself.

What follows sets out the method’s formal structure as a linear factor model, why this structure nests difference-in-differences as a special case, the geometry of the convex hull constraint and what it buys over regression, the placebo-based inference required when the number of treated units is one, and the two principal extensions, augmented synthetic control (Ben-Michael, Feller and Rothstein, 2021) and synthetic difference-in-differences (Arkhangelsky et al., 2021), that relax its assumptions when pre-treatment fit is imperfect or multiple treated units are available.

Why Standard Methods Break

The Single Treated Unit Problem

DiD was designed for settings with many treated and control units, where aggregation smooths out idiosyncratic differences. With a single treated unit, two things go wrong simultaneously. First, you have no way to average out unit-specific shocks, one unusual event in Portugal post-launch gets absorbed into your treatment effect estimate. Second, conventional inference collapses: cluster-robust standard errors require multiple clusters to estimate a variance; with \(N = 1\) treated cluster, your p-values are unreliable. Abadie et al. (2010) highlight this as a fundamental barrier, you cannot estimate a variance from one observation.

Code
years <- 2018:2025
treat_time <- 2024
n <- length(years)

portugal <- 100 + cumsum(c(0, rnorm(n - 1, mean = 2.5, sd = 1.0)))
espanha  <- 100 + cumsum(c(0, rnorm(n - 1, mean = 1.2, sd = 1.0)))
franca   <- 100 + cumsum(c(0, rnorm(n - 1, mean = 1.5, sd = 0.8)))
grecia   <- 100 + cumsum(c(0, rnorm(n - 1, mean = 0.7, sd = 1.1)))

portugal[years >= treat_time] <- portugal[years >= treat_time] +
  cumsum(rep(3.5, sum(years >= treat_time)))

df_trends <- data.frame(
  year    = rep(years, 4),
  index   = c(portugal, espanha, franca, grecia),
  country = rep(c("Portugal (treated)", "Spain", "France", "Greece"), each = n)
)

ggplot(df_trends, aes(x = year, y = index, colour = country, linetype = country,
                       linewidth = country)) +
  geom_line() +
  geom_vline(xintercept = treat_time, linetype = "dashed",
             colour = "grey30", linewidth = 0.8) +
  annotate("text", x = treat_time + 0.1, y = 115,
           label = "Feature launch", hjust = 0, size = 3.3, colour = "grey30") +
  scale_colour_manual(values = c(
    "Portugal (treated)" = "#D62728",
    "Spain"              = "#1F77B4",
    "France"             = "#FF7F0E",
    "Greece"             = "#9467BD"
  )) +
  scale_linetype_manual(values = c(
    "Portugal (treated)" = "solid",
    "Spain"              = "dashed",
    "France"             = "dotdash",
    "Greece"             = "dotted"
  )) +
  scale_linewidth_manual(values = c(
    "Portugal (treated)" = 1.4,
    "Spain"              = 0.8,
    "France"             = 0.8,
    "Greece"             = 0.8
  )) +
  labs(
    title    = "90-Day Retention Index by Country",
    subtitle = "Portugal grows systematically faster, parallel trends is indefensible",
    x = "Year", y = "Retention Index (2018 = 100)",
    colour = NULL, linetype = NULL, linewidth = NULL
  ) +
  theme(legend.position = "bottom") +
  annotate("label", x = 2019, y = 108,
           label = "Pre-treatment divergence:\nParallel Trends violated",
           colour = "#D62728", fill = "white",
           fontface = "italic", size = 3)

Divergent pre-treatment trajectories: Portugal vs. candidate comparators (simulated illustrative data). DiD requires these lines to be parallel, they clearly are not.

Conclusion: We need a method that constructs the counterfactual rather than assuming it. This is where Synthetic Control comes in.

The Core Idea: Building an Artificial Twin

The Synthetic Control Method (SCM) does something conceptually simple but methodologically powerful: instead of selecting one control country or taking an equal average of several, it constructs an optimally weighted convex combination of donor pool units that best replicates the pre-treatment characteristics of the treated unit.

As Abadie, Diamond & Hainmueller (2010) put it:

“The idea behind the synthetic control approach is that a combination of units often provides a better comparison for the unit exposed to the intervention than any single unit alone.”

Think of it as building a composite control group, stitching together pieces from different units to create a blend that mirrors the treated unit. You want Portugal’s profile, but you don’t have “Portugal without the feature.” You have Spain, France, Italy, Greece. The SCM finds the recipe that best approximates it, perhaps 40% Spain, 30% Italy, 30% Greece, and uses that blend as the counterfactual. No single country matches Portugal, but a weighted combination can come remarkably close. The method lets the data decide which combination works best, replacing analyst judgment with algorithmic optimisation.

The diagram below summarises the synthetic control pipeline (adapted from Mendez, 2023).

%%{init: {'theme':'base','themeVariables':{'primaryColor':'#EBF5FB','edgeLabelBackground':'#fff','fontSize':'14px'}}}%%
flowchart TD
    A["<b>Treated unit</b><br/>(e.g. Portugal)"]:::treated
    B["<b>Donor pool</b><br/>(11 other markets)"]:::donor

    A --> C["Pre-treatment<br/>predictors X₁, Z₁"]:::step
    B --> D["Pre-treatment<br/>predictors X₀, Z₀"]:::step

    C --> E["<b>Inner optimisation</b><br/>Solve W* given V"]:::opt
    D --> E

    E --> F["<b>Outer optimisation</b><br/>Solve V* that minimises<br/>pre-treatment MSPE"]:::opt

    F --> G["<b>Optimal weights W*</b><br/>sparse, non-negative, sum to 1"]:::synth
    B --> G

    G --> I["Synthetic control<br/>= Σ wⱼ · Yⱼₜ<br/>(counterfactual path)"]:::counter
    A --> H["Actual post-treatment path"]:::actual

    H --> J["<b>Gap = ATT estimate</b>"]:::att
    I --> J

    classDef treated  fill:#E05C3A,color:#fff,stroke:none
    classDef donor    fill:#5B9BD5,color:#fff,stroke:none
    classDef step     fill:#B0C4DE,color:#1a1a2e,stroke:#8fa8c8
    classDef opt      fill:#B0C4DE,color:#1a1a2e,stroke:#8fa8c8
    classDef synth    fill:#5B9BD5,color:#fff,stroke:none
    classDef actual   fill:#E05C3A,color:#fff,stroke:none
    classDef counter  fill:#f5f5f5,color:#333,stroke:#aaa
    classDef att      fill:#00B4A6,color:#fff,stroke:none

Trajectory Matching: What SCM Actually Does

Imagine a graph with time on the horizontal axis and the outcome on the vertical. Before treatment, you see two lines: the solid red line of Portugal’s actual trajectory, and the dashed blue line of its synthetic counterpart. In the pre-treatment period, these two lines are nearly indistinguishable, they move together, dip together, recover together. This is not coincidence; it is the optimisation at work.

Then, at the treatment date, the lines diverge. Portugal’s actual trajectory continues upward while the synthetic trajectory follows the path that Portugal would have taken in the absence of treatment. The vertical distance between the two lines, the gap, is the estimated causal effect at each point in time.

This is trajectory matching in its purest form. Unlike DiD, which matches on average trends (a single number), SCM matches on the entire pre-treatment path. If the synthetic unit reproduces Portugal’s trajectory through booms, recessions, seasonal fluctuations, and idiosyncratic shocks, then its post-treatment path is a far more credible counterfactual than any simple before-after comparison. Huntington-Klein (2021) frames SCM as “a variant of difference-in-differences with matching,” but with key distinctions: the matching is done on pre-treatment outcomes rather than treatment propensity; it requires many pre-treatment periods; and inference uses randomisation inference (placebo tests) rather than conventional standard errors.

The Convex Hull: Why Weights Must Be Non-Negative

The restriction \(w_j \geq 0,\ \sum_j w_j = 1\) is not a mere technical detail, it is the defining feature that separates SCM from regression. Abadie (2020, Section 5) explains:

“The synthetic control method guards against extrapolation outside the convex hull of the data because weights from all control units can be chosen to be positive and sum to one.”

Geometrically, the convex hull is the polytope formed by the characteristics of donor pool units. If the treated unit falls inside this hull, the SCM finds an exact match. If it falls outside, the SCM projects it onto the hull boundary, and transparently signals an imperfect fit. The convex hull constraint ensures that the synthetic control is always a plausible unit, one that could actually exist given the data you observe.

NoteRegression vs. Synthetic Control: a hidden difference

Regression is also a weighting estimator, its weights sum to one when an intercept is included. The critical difference is that regression weights are unconstrained: they can be negative, and they can exceed one. This means regression can extrapolate outside the observed data without any warning.

Abadie, Diamond & Hainmueller (2015) demonstrate this concretely in their study of German reunification. When they estimate the effect using OLS, the implied weights include Greece at −0.09, Italy at −0.05, and Portugal at −0.08. Negative weights mean the regression is constructing a counterfactual that no combination of actual countries could produce. The SCM, by contrast, assigns positive weights only to Austria (0.42), the USA (0.22), Japan (0.16), Switzerland (0.11), and the Netherlands (0.09). Every weight corresponds to a real country.

The SCM’s non-negativity constraint is not a restriction, it is a guard against invisible extrapolation. Note, however, that SCM avoids extrapolation in observed covariate space, while identification still relies on the underlying latent factor structure. A synthetic unit inside the convex hull is a plausible unit, but plausibility does not guarantee that the latent factor model holds.

Code
set.seed(7)
ctrl_pts <- data.frame(
  x     = c(2.1, 4.5, 6.8, 7.2, 5.9, 3.3, 1.5, 4.1),
  y     = c(3.0, 1.2, 2.5, 5.1, 6.8, 7.0, 5.2, 4.5),
  label = paste0("Control ", LETTERS[1:8])
)

portugal_pt <- data.frame(x = 4.2, y = 4.0, label = "Portugal (actual)")
synth_pt    <- data.frame(x = 4.0, y = 3.7, label = "Portugal Synthetic")
extrap_pt   <- data.frame(x = 8.8, y = 7.8, label = "OLS extrapolation\n(negative weights)")

hull_idx  <- chull(ctrl_pts$x, ctrl_pts$y)
hull_pts  <- ctrl_pts[c(hull_idx, hull_idx[1]), ]

ggplot() +
  geom_polygon(data = hull_pts, aes(x = x, y = y),
               fill = "#ADE8F4", alpha = 0.35, colour = "#023E8A", linewidth = 0.8) +
  geom_point(data = ctrl_pts, aes(x = x, y = y), size = 4, colour = "#0077B6") +
  geom_text(data = ctrl_pts, aes(x = x, y = y, label = label),
            nudge_y = 0.28, size = 3, colour = "#0077B6") +
  geom_point(data = portugal_pt, aes(x = x, y = y),
             size = 5, colour = "#D62728", shape = 17) +
  geom_text(data = portugal_pt, aes(x = x, y = y, label = label),
            nudge_y = -0.35, size = 3.5, colour = "#D62728", fontface = "bold") +
  geom_point(data = synth_pt, aes(x = x, y = y),
             size = 6, colour = "#EE9B00", shape = 8, stroke = 1.5) +
  geom_text(data = synth_pt, aes(x = x, y = y, label = label),
            nudge_y = 0.35, size = 3.5, colour = "#EE9B00", fontface = "bold") +
  geom_point(data = extrap_pt, aes(x = x, y = y),
             size = 5, colour = "#9B2226", shape = 4, stroke = 2) +
  geom_text(data = extrap_pt, aes(x = x, y = y, label = label),
            nudge_y = -0.5, size = 3, colour = "#9B2226") +
  annotate("label", x = 4.3, y = 1.0,
           label = "Safe zone: synthetic in convex hull",
           size = 3, fill = "#ADE8F4", colour = "#023E8A") +
  labs(
    title    = "Convex Hull of the Donor Pool: the frontier of safe interpolation",
    subtitle = "W* ≥ 0 and Σw = 1 prevent extrapolation beyond observed data",
    x = "Pre-treatment characteristic 1 (e.g., GDP per capita)",
    y = "Pre-treatment characteristic 2 (e.g., digital penetration rate)"
  )

Geometric representation of the convex hull (2D simplified). The SCM only interpolates, it never extrapolates in observed covariate space. OLS regression, by contrast, allows negative weights and extrapolation outside the observed data support.
Code
weights_ex <- data.frame(
  Country = c("Spain", "Italy", "Greece", "Czech Republic",
              "Remaining 8 units (Weight = 0.000)"),
  Weight  = c(0.35, 0.28, 0.19, 0.18, 0.00),
  check.names = FALSE
)

kable(weights_ex, booktabs = TRUE, digits = 2, align = "lr") |>
  kable_styling(bootstrap_options = c("striped", "hover"), full_width = TRUE, font_size = 12) |>
  column_spec(2, bold = TRUE, color = "#005F73") |>
  row_spec(5, italic = TRUE, color = "grey50") |>
  add_footnote(
    "These weights are hypothetical. In practice they are determined by constrained numerical optimisation (MSPE minimisation). Weights are algorithmic outputs of the interpolation geometry and should not be subject to post-hoc qualitative rationalisation, e.g. 'Spain has the highest weight because of Iberian market similarities' confuses the geometry of fit with substantive similarity.",
    notation = "none"
  )
Hypothetical example of synthetic weights. Non-negative weights summing to 1 ensure the synthetic control lies within the convex hull of the donor pool. Only units with positive weight are shown individually.
Country Weight
Spain 0.35
Italy 0.28
Greece 0.19
Czech Republic 0.18
Remaining 8 units (Weight = 0.000) 0.00
These weights are hypothetical. In practice they are determined by constrained numerical optimisation (MSPE minimisation). Weights are algorithmic outputs of the interpolation geometry and should not be subject to post-hoc qualitative rationalisation, e.g. 'Spain has the highest weight because of Iberian market similarities' confuses the geometry of fit with substantive similarity.

Formal Setup

The Factor Model Justification

Following Abadie et al. (2010, JASA), suppose we observe \(J + 1\) units over \(T\) periods. Only unit \(i = 1\) is treated, from period \(T_0 + 1\) onward. The potential outcome without treatment follows a linear factor model:

\[ Y_{it}^{(0)} = \delta_t + \boldsymbol{\theta}_t' \mathbf{Z}_i + \boldsymbol{\lambda}_t' \boldsymbol{\mu}_i + \varepsilon_{it} \tag{1} \]

Symbol Meaning
\(\delta_t\) Common time trend
\(\mathbf{Z}_i\) Observed covariates unaffected by treatment
\(\boldsymbol{\theta}_t\) Time-varying coefficients on observed covariates
\(\boldsymbol{\lambda}_t\) Time-varying unobserved common factors
\(\boldsymbol{\mu}_i\) Unit-specific factor loadings
\(\varepsilon_{it}\) Transitory shocks with zero mean

Critical point: The standard DiD model is a special case of (1) with \(\boldsymbol{\lambda}_t = \boldsymbol{\lambda}\) (constant over time). In that case, \(\boldsymbol{\lambda}' \boldsymbol{\mu}_i\) reduces to a unit fixed effect \(\alpha_i\), and differencing removes it, that is the parallel trends assumption in action. SCM is more general: it allows unobserved confounder effects to vary over time, making simple differencing impossible. This is why DiD with violated parallel trends is not merely inconvenient, it is fundamentally identified off the wrong model.

The diagram below illustrates how DiD and SCM differ in the assumptions they make about the factor structure.

%%{init: {'theme':'base','themeVariables':{'primaryColor':'#F8F9FA','fontSize':'13px'}}}%%
flowchart LR
    subgraph DiD["DiD, Restricted Factor Model"]
        direction TB
        A["λₜ = λ (constant)"] --> B["λ'μᵢ = αᵢ (unit FE)"]
        B --> C["Differencing removes αᵢ"]
        C --> D["Requires: parallel trends"]
    end
    subgraph SCM["SCM, General Factor Model"]
        direction TB
        E["λₜ varies over time"] --> F["λₜ'μᵢ unobservable per period"]
        F --> G["Matched via pre-treatment trajectory"]
        G --> H["Requires: good pre-treatment fit<br/> + long T0"]
    end
    DiD ~~~ SCM
    style DiD fill:#FFF3CD,stroke:#F0AD4E,color:#000
    style SCM fill:#D4EDDA,stroke:#28A745,color:#000

The SCM Estimator

The goal is to estimate the counterfactual \(Y_{1t}^{(0)}\) for the treated unit post-treatment:

\[ \hat{Y}_{1t}^{(0)} = \sum_{j=2}^{J+1} w_j^* \cdot Y_{jt} \]

The optimal weights \(\mathbf{W}^* = (w_2^*, \ldots, w_{J+1}^*)'\) solve:

\[ \mathbf{W}^* = \arg\min_{\mathbf{W}} \left(\mathbf{X}_1 - \mathbf{X}_0 \mathbf{W}\right)' \mathbf{V} \left(\mathbf{X}_1 - \mathbf{X}_0 \mathbf{W}\right) \tag{2} \]

subject to \(w_j \geq 0\) and \(\sum_{j=2}^{J+1} w_j = 1\), where \(\mathbf{X}_1\) is the \((k \times 1)\) vector of pre-treatment characteristics for the treated unit, \(\mathbf{X}_0\) is the \((k \times J)\) matrix of the same characteristics for controls, and \(\mathbf{V}\) is a diagonal positive-definite matrix weighting the importance of each predictor.

The treatment effect at each post-treatment period — i.e., the ATT at time \(t\) — is:

\[ \hat{\alpha}_{1t} = Y_{1t} - \hat{Y}_{1t}^{(0)} = Y_{1t} - \sum_{j=2}^{J+1} w_j^* Y_{jt}, \quad t > T_0 \tag{3} \]

The matrix \(\mathbf{V}\) is chosen to minimise the Mean Squared Prediction Error (MSPE) in the pre-treatment period:

\[ \mathbf{V}^* = \arg\min_{\mathbf{V}} \sum_{t=1}^{T_0} \left(Y_{1t} - \sum_{j=2}^{J+1} \hat{w}_j(\mathbf{V}) \cdot Y_{jt}\right)^2 \tag{4} \]

This creates a nested optimisation: for each candidate \(\mathbf{V}\), solve (2) for \(\mathbf{W}^*(\mathbf{V})\); then select the \(\mathbf{V}\) producing the best pre-treatment fit. The Synth package implements this via optim() (BFGS or Nelder-Mead) at the outer level and ipop() (from kernlab) at the inner level [@adh2011].

TipWhy sparsity of weights is a feature, not a bug

The number of non-zero weights is bounded above by the number of predictors \(k\). With four predictors, at most four donor units will receive positive weight. This sparsity is a geometric property of convex hull interpolation, not an artificially imposed penalty — a sparse solution means the synthetic control is anchored to a small number of structurally similar donors, not an average of everything.

If all weights are approximately \(1/J\), that is a red flag: the synthetic control is indistinguishable from a simple donor mean, and you are gaining nothing over naive DiD.

Abadie et al. [-@abadie2010] show that if \(\mathbf{W}^*\) satisfies:

\[ \sum_{j=2}^{J+1} w_j^* Y_{jt} = Y_{1t} \quad \forall\, t \leq T_0, \qquad \sum_{j=2}^{J+1} w_j^* \mathbf{Z}_j = \mathbf{Z}_1 \]

then under the factor model the bias of \(\hat{\alpha}_{1t}\) is bounded by a term that decreases with \(T_0\). More pre-treatment periods yield a tighter bias bound — this is why SCM requires long pre-treatment series and is not recommended when \(T_0\) is small.

ImportantMinimum pre-treatment periods

The bias bound shrinks with \(T_0\). As a practical guideline: with fewer than 10 pre-treatment periods, proceed with caution and run extensive robustness checks; with fewer than 5, switch to ASCM or accept that causal attribution is not achievable with the available data.

This is a rule of thumb, not a sharp identification condition. A short \(T_0\) with perfect pre-treatment fit may still be credible; a long \(T_0\) with poor fit is not.

When SCM Works vs. When It Fails

Requirements for a Valid Application

The diagram below maps the conditions under which SCM is valid, problematic, or should be replaced by an alternative.

%%{init: {'theme':'base','themeVariables':{'primaryColor':'#EBF5FB'}}}%%
flowchart TD
    A["Do you have a single treated unit?"] -->|Yes| B["Is T0 ≥ 10?"]
    A -->|"No (multiple units)"| Z1["→ Consider SDID<br/>or staggered DiD"]
    B -->|Yes| C["Is pre-treatment MSPE low?<br/>(synthetic tracks treated trajectory)"]
    B -->|No| Z2["→ SCM bias may be high.<br/>Use ASCM or report<br/>with extreme caution"]
    C -->|"Yes, excellent fit"| D["Is treated unit inside<br/>convex hull of donor pool?"]
    C -->|"No, poor fit"| Z3["→ ASCM with ridge correction<br/>(Ben-Michael et al. 2021)"]
    D -->|Yes| E["Classic SCM: proceed<br/>Run both placebo tests"]
    D -->|"No (outlier unit)"| Z4["→ ASCM or flag clearly<br/>SCM will extrapolate<br/>to hull boundary"]
    style E fill:#D4EDDA,stroke:#28A745
    style Z1 fill:#FFF3CD,stroke:#F0AD4E
    style Z2 fill:#F8D7DA,stroke:#DC3545
    style Z3 fill:#FFF3CD,stroke:#F0AD4E
    style Z4 fill:#FFF3CD,stroke:#F0AD4E

Overfitting Risk

When the number of pre-treatment periods (\(T_0\)) is small relative to the number of donor pool units (\(J\)), the SCM can overfit: it finds weights that match the pre-treatment trajectory by chance rather than by capturing genuine structural similarity. A synthetic unit that perfectly tracks the treated unit through three pre-treatment periods tells you almost nothing, any random combination of three donors can match three points.

Rich Donor Pool Construction

SCM requires a donor pool with sufficient variety to construct the synthetic unit. Abadie et al. (2015) recommend three exclusion criteria:

  1. Remove units affected by the same event as the treated unit (contaminated controls).
  2. Remove units with large idiosyncratic shocks during the analysis window.
  3. Restrict to units similar enough to the treated unit to avoid interpolation bias.
WarningDonor pool construction matters more than the optimisation

The weights are only as good as the pool they draw from. Including a unit affected by the same treatment, or a correlated structural shock, biases the synthetic control toward zero, attenuating your estimate. A bad answer to the question “how did you build your donor pool?” invalidates the entire analysis, regardless of how sophisticated the weight optimisation is.

Do not answer this question with “we included all available units.” Convenience is not a validity criterion.

Inference: The Fundamental Problem with N = 1

Conventional standard errors are inapplicable with a single treated unit. Abadie et al. (2010) propose permutation (placebo) tests as the only principled inferential tool in this setting.

In-Space Placebo Test

The procedure: iteratively apply SCM to each unit in the donor pool as if it were treated, collect the “placebo gaps,” and compare the true gap against this null distribution.

The exact permutation p-value is:

\[ p = \frac{1}{J+1} \sum_{j=1}^{J+1} \mathbf{1}\left(r_j \geq r_1\right), \quad r_j = \frac{\text{MSPE}_{\text{post}}^{(j)}}{\text{MSPE}_{\text{pre}}^{(j)}} \tag{5} \]

where \(r_j\) is the ratio of post-treatment MSPE to pre-treatment MSPE for unit \(j\). This ratio penalises placebos with poor pre-treatment fit, which would otherwise trivially produce large post-treatment gaps. If the treated unit’s ratio is the largest in the distribution, the exact p-value is \(1/(J+1)\), the smallest achievable given the sample size.

ImportantInterpreting the permutation p-value correctly

The permutation p-value is not equivalent to a classical asymptotic p-value. It answers: “In how many permutations does the placebo gap ratio exceed the real gap ratio?” An exact p-value of \(1/39 \approx 0.026\) means only 1 of 39 permutations produces a gap as large as the one observed for California, this constitutes evidence against the null of no effect.

Do not say “significant at the 5% level” in the classical sense. Say instead: “the post-treatment gap for the treated unit is larger than all placebo gaps in the distribution,” or “the permutation p-value is \(1/(J+1)\), providing evidence against the null of no effect.”

Note also that \(1/(J+1)\) is the floor of achievable p-values given a donor pool of size \(J\). With a small donor pool, this limits the resolution of your test, another reason a large, carefully constructed donor pool is valuable.

In-Time Placebo Test

A mandatory companion is the in-time placebo: simulate the treatment several years early (e.g., in 1980 instead of 1989). If the SCM is sound, the gap after this fictitious treatment date should be approximately zero. A substantively large “effect” before the real treatment signals overfitting or pre-existing confounders, in which case the post-treatment gap cannot be credibly attributed to the treatment.

WarningNever present SCM results without running both placebo tests

An in-space placebo test tells you whether your gap is unusual relative to a null distribution. An in-time placebo test tells you whether the pre-treatment fit is genuinely capturing structural similarity or merely fitting noise. Running only one gives you half the picture. A result that passes one but fails the other should not be presented as credible evidence.

This is not a suggestion, it is the minimum standard for an SCM analysis to be taken seriously.

Example: California Proposition 99

The benchmark application in the SCM literature is Abadie et al. (2010)’s study of Proposition 99, California’s 1988 tobacco control program. California raised cigarette taxes by 25 cents per pack and funded anti-smoking media campaigns. The question: how many packs per capita did Proposition 99 actually prevent?

The setting is a perfect SCM case: one treated unit (California), a rich donor pool (38 other US states), and a long pre-treatment period (1970–1988). California was already on a different trajectory from the average state, higher taxes, stronger anti-smoking sentiment, and a steeper pre-existing decline. The simple donor average is a poor counterfactual. This is exactly the setting where DiD breaks and SCM shines.

Data Preparation

Code
for (p in c("Synth", "tidysynth")) {
  if (!requireNamespace(p, quietly = TRUE))
    install.packages(p, repos = "https://cloud.r-project.org", quiet = TRUE)
}
invisible(suppressWarnings(suppressPackageStartupMessages({
  library(Synth)
  library(tidysynth)
})))

data("smoking", package = "tidysynth")
smoking <- as.data.frame(smoking)
smoking$state_id <- as.numeric(factor(smoking$state, levels = sort(unique(smoking$state))))

ca_id       <- unique(smoking$state_id[smoking$state == "California"])
control_ids <- setdiff(unique(smoking$state_id), ca_id)

invisible(capture.output(
  dataprep_out <- dataprep(
    foo                   = smoking,
    predictors            = c("lnincome", "retprice", "age15to24", "beer"),
    predictors.op         = "mean",
    time.predictors.prior = 1980:1988,
    special.predictors    = list(
      list("cigsale", 1975, "mean"),
      list("cigsale", 1980, "mean"),
      list("cigsale", 1988, "mean")
    ),
    dependent             = "cigsale",
    unit.variable         = "state_id",
    unit.names.variable   = "state",
    time.variable         = "year",
    treatment.identifier  = ca_id,
    controls.identifier   = control_ids,
    time.optimize.ssr     = 1970:1988,
    time.plot             = 1970:2000
  )
))

Estimation

Code
synth_out    <- synth(data.prep.obj = dataprep_out, method = "BFGS")
synth_tables <- synth.tab(dataprep.res = dataprep_out, synth.res = synth_out)

The synthetic California is built from a handful of states that reproduce California’s pre-1989 profile. Most states receive zero weight, they are too dissimilar to contribute. This sparsity is a natural geometric property of convex hull interpolation, bounded by the number of predictors \(k = 7\) (4 averaged predictors plus 3 special predictors for cigsale at 1975, 1980, and 1988).

Code
synth_tables$tab.pred |>
  kable(booktabs = TRUE, digits = 2,
        col.names = c("California (actual)", "California Synthetic", "38-state mean")) |>
  kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)
Pre-treatment balance: California actual vs. California Synthetic vs. 38-state donor mean. The synthetic control replicates California’s pre-1989 characteristics far better than the simple donor average.
California (actual) California Synthetic 38-state mean
lnincome 10.08 9.86 9.83
retprice 89.42 89.32 87.27
age15to24 0.17 0.17 0.17
beer 24.28 24.09 23.66
special.cigsale.1975 127.10 126.90 136.93
special.cigsale.1980 120.20 120.25 138.09
special.cigsale.1988 90.10 91.43 113.82
Code
synth_tables$tab.w |>
  dplyr::filter(w.weights > 0.001) |>
  dplyr::arrange(desc(w.weights)) |>
  kable(booktabs = TRUE, digits = 3,
        col.names = c("Weight W*", "State", "State No.")) |>
  kable_styling(bootstrap_options = c("striped"), full_width = FALSE) |>
  column_spec(1, bold = TRUE, color = "#005F73") |>
  add_footnote(
    "These weights are the output of MSPE minimisation. Their interpretation is geometric (which donors minimise pre-treatment fit error) rather than substantive (which donors are 'most similar' to California in some qualitative sense).",
    notation = "none"
  )
Synthetic weights W* for California. Only states with weight > 0.001 are shown individually. Sparsity, most states receiving zero weight, is a geometric property of the convex hull, not an artificially imposed constraint.
Weight W* State State No.
34 0.343 South Carolina 30
21 0.236 Maine 15
19 0.182 Texas 33
4 0.175 Indiana 10
5 0.062 Nevada 21
These weights are the output of MSPE minimisation. Their interpretation is geometric (which donors minimise pre-treatment fit error) rather than substantive (which donors are 'most similar' to California in some qualitative sense).

Path Plot and Gap Plot

Code
years_ca    <- 1970:2000
y_actual    <- as.numeric(dataprep_out$Y1plot)
y_synthetic <- as.numeric(dataprep_out$Y0plot %*% synth_out$solution.w)

df_path <- data.frame(
  year  = rep(years_ca, 2),
  packs = c(y_actual, y_synthetic),
  serie = rep(c("California (actual)", "California Synthetic"), each = length(years_ca))
)

ggplot(df_path, aes(x = year, y = packs, colour = serie, linetype = serie)) +
  geom_line(linewidth = 1.2) +
  geom_vline(xintercept = 1989, linetype = "dashed", colour = "grey30", linewidth = 0.8) +
  annotate("text", x = 1989.3, y = 130, label = "Proposition 99\n(Jan 1989)",
           hjust = 0, size = 3.3, colour = "grey30") +
  scale_colour_manual(values = c(
    "California (actual)"  = "#D62728",
    "California Synthetic" = "#1F77B4"
  )) +
  scale_linetype_manual(values = c("solid", "longdash")) +
  labs(
    title    = "Effect of Proposition 99 on Cigarette Sales",
    subtitle = "Synthetic Control Method, replication of Abadie, Diamond & Hainmueller (2010)",
    x = "Year", y = "Per capita cigarette sales (packs)",
    colour = NULL, linetype = NULL
  ) +
  theme(legend.position = "bottom")

Per-capita cigarette sales: California actual vs. California Synthetic, 1970–2000. The pre-1989 lines are nearly indistinguishable, the synthetic unit reproduces California’s trajectory through economic cycles and seasonal patterns. The post-1988 divergence estimates the causal effect of Proposition 99.
Code
df_gap      <- data.frame(year = years_ca, gap = y_actual - y_synthetic)
effect_2000 <- round(df_gap$gap[df_gap$year == 2000], 1)

ggplot(df_gap, aes(x = year, y = gap)) +
  geom_hline(yintercept = 0, linetype = "dashed", colour = "grey50") +
  geom_vline(xintercept = 1989, linetype = "dashed", colour = "grey30", linewidth = 0.8) +
  geom_ribbon(data = subset(df_gap, year >= 1989),
              aes(ymin = gap, ymax = 0), fill = "#9B2226", alpha = 0.15) +
  geom_line(linewidth = 1.2, colour = "#005F73") +
  geom_point(size = 2, colour = "#005F73") +
  annotate("text", x = 2000.2, y = effect_2000 - 1,
           label = paste0(effect_2000, " packs\nper capita"),
           hjust = 0, size = 3.2, colour = "#9B2226") +
  labs(
    title    = "Estimated Causal Gap: California − California Synthetic",
    subtitle = "Pre-1989: gap ≈ 0 (good fit). Post-1989: growing causal effect.",
    x = "Year", y = "Gap in per capita sales (packs)"
  ) +
  scale_x_continuous(breaks = seq(1970, 2000, 5))

Gap (actual minus synthetic California). Pre-1989: gap near zero confirms good pre-treatment fit. Post-1989: growing negative gap represents the causal reduction in cigarette sales attributable to Proposition 99. The shaded region marks the cumulative treatment effect.

In-Space Placebo Test

Code
store_gaps  <- matrix(NA, nrow = length(1970:2000), ncol = length(control_ids))
colnames(store_gaps) <- paste0("s", control_ids)

for (i in seq_along(control_ids)) {
  ctrl_i  <- control_ids[i]
  donor_i <- setdiff(control_ids, ctrl_i)
  tryCatch({
    invisible(capture.output({
      dp_i <- dataprep(
        foo                   = smoking,
        predictors            = c("lnincome", "retprice", "age15to24", "beer"),
        predictors.op         = "mean",
        time.predictors.prior = 1980:1988,
        special.predictors    = list(
          list("cigsale", 1975, "mean"),
          list("cigsale", 1980, "mean"),
          list("cigsale", 1988, "mean")
        ),
        dependent             = "cigsale",
        unit.variable         = "state_id",
        unit.names.variable   = "state",
        time.variable         = "year",
        treatment.identifier  = ctrl_i,
        controls.identifier   = donor_i,
        time.optimize.ssr     = 1970:1988,
        time.plot             = 1970:2000
      )
      so_i <- synth(data.prep.obj = dp_i, method = "BFGS", Sigf.ipop = 3)
    }))
    store_gaps[, i] <- dp_i$Y1plot - (dp_i$Y0plot %*% so_i$solution.w)
  }, error = function(e) NULL)
}
Code
years_num <- 1970:2000
pre_idx   <- years_num < 1989

mse_pre <- apply(store_gaps[pre_idx, ], 2, function(x) mean(x^2, na.rm = TRUE))
ca_mse  <- mean((y_actual - y_synthetic)[pre_idx]^2)
keep    <- which(mse_pre < 5 * ca_mse)

df_placebo <- as.data.frame(store_gaps[, keep]) |>
  dplyr::mutate(year = years_num) |>
  tidyr::pivot_longer(-year, names_to = "state", values_to = "gap") |>
  dplyr::filter(!is.na(gap))

ca_gap_df <- data.frame(year = years_num, gap = y_actual - y_synthetic)

ggplot() +
  geom_line(data = df_placebo,
            aes(x = year, y = gap, group = state),
            colour = "grey65", alpha = 0.55, linewidth = 0.45) +
  geom_hline(yintercept = 0, linetype = "dashed", colour = "grey40") +
  geom_vline(xintercept = 1989, linetype = "dashed", colour = "grey30", linewidth = 0.8) +
  geom_line(data = ca_gap_df, aes(x = year, y = gap),
            colour = "#005F73", linewidth = 1.6) +
  annotate("text", x = 1972, y = min(ca_gap_df$gap, na.rm = TRUE) + 3,
           label = "California", colour = "#005F73", fontface = "bold", size = 3.5) +
  annotate("text", x = 1989.5, y = 40,
           label = "Prop. 99", hjust = 0, size = 3, colour = "grey30") +
  labs(
    title    = "Placebo Test: Permutation Inference",
    subtitle = paste0("California gap vs. distribution of ", length(keep),
                      " placebos (MSPE_pre < 5× CA). p = 1/39 ≈ 0.026."),
    x = "Year", y = "Gap in per capita sales (packs)"
  ) +
  scale_x_continuous(breaks = seq(1970, 2000, 5))

In-space placebo test: California gap (blue) vs. distribution of placebo gaps (grey). Only placebos with pre-treatment MSPE less than 5 times California’s are shown, donors with poor pre-treatment fit would trivially produce large post-treatment gaps and are not informative about the null distribution. California’s gap stands clearly outside the placebo cloud post-1989.

Abadie et al. (2010) report an exact permutation p-value of \(1/39 \approx 0.026\), California’s post-treatment MSPE ratio is larger than that of any other state. This means: in every permutation of the placebo test, California produces the largest gap ratio. This constitutes evidence against the null of no effect, using the only valid inferential framework available with a single treated unit.

In-Time Placebo Test

Code
invisible(capture.output({
  dp_placebo_time <- dataprep(
    foo                   = smoking,
    predictors            = c("retprice", "age15to24"),
    predictors.op         = "mean",
    time.predictors.prior = 1970:1979,
    special.predictors    = list(
      list("cigsale", 1970, "mean"),
      list("cigsale", 1975, "mean"),
      list("cigsale", 1979, "mean")
    ),
    dependent             = "cigsale",
    unit.variable         = "state_id",
    unit.names.variable   = "state",
    time.variable         = "year",
    treatment.identifier  = ca_id,
    controls.identifier   = control_ids,
    time.optimize.ssr     = 1970:1979,
    time.plot             = 1970:1988
  )
  so_pt <- synth(data.prep.obj = dp_placebo_time, method = "BFGS", Sigf.ipop = 3)
}))
y_pt_actual <- as.numeric(dp_placebo_time$Y1plot)
y_pt_synth  <- as.numeric(dp_placebo_time$Y0plot %*% so_pt$solution.w)
anos_pt     <- 1970:1988
Code
df_pt2 <- data.frame(
  year  = rep(anos_pt, 2),
  packs = c(y_pt_actual, y_pt_synth),
  serie = rep(c("California (actual)", "California Synthetic"), each = length(anos_pt))
)

ggplot(df_pt2, aes(x = year, y = packs, colour = serie, linetype = serie)) +
  geom_line(linewidth = 1.2) +
  geom_vline(xintercept = 1980, linetype = "dashed", colour = "#9B2226") +
  annotate("text", x = 1980.2, y = 105,
           label = "Placebo treatment\n(fictitious)", hjust = 0,
           colour = "#9B2226", size = 3.2) +
  scale_colour_manual(values = c("California (actual)" = "#D62728",
                                  "California Synthetic" = "#1F77B4")) +
  scale_linetype_manual(values = c("solid", "longdash")) +
  labs(
    title    = "In-Time Placebo: Fictitious Treatment in 1980",
    subtitle = "Series should track closely after 1980 if no pre-existing confounders or anticipation",
    x = "Year", y = "Per capita cigarette sales (packs)",
    colour = NULL, linetype = NULL
  ) +
  theme(legend.position = "bottom")

In-time placebo: fictitious treatment in 1980 (9 years before Proposition 99). The two series remain close after 1980, no evidence of anticipation effects or pre-existing confounders. A large gap here would have undermined the credibility of the 1989 estimate.

The two series remain close after the fictitious 1980 treatment date. This is the expected outcome under a valid SCM, it confirms that the pre-treatment fit reflects genuine structural similarity, not overfitting to a short window.

The Evolution: ASCM and SDID

The classic SCM is the right tool when pre-treatment fit is excellent and you have a single treated unit. But two natural extensions address its limitations.

When SCM Fails: The Imperfect Fit Problem

SCM’s convex hull constraint is its greatest strength, no extrapolation in observed covariate space, but also its greatest limitation. When the treated unit is an outlier relative to the donor pool, no convex combination of donors can reproduce the treated unit’s pre-treatment trajectory. The fit will be poor, and the resulting counterfactual will be biased. Abadie (2020) is explicit: if the pre-treatment fit is poor, you should not use SCM.

Augmented SCM: Ridge-Corrected Bias Reduction

Ben-Michael, Feller & Rothstein (2021) propose the Augmented Synthetic Control Method (ASCM), which augments the standard SCM estimator with an outcome model that corrects for the bias from imperfect pre-treatment fit. The key idea: if the SCM counterfactual has a residual error in the pre-treatment period, fit an outcome model to those residuals and use it to adjust the post-treatment prediction.

Formally, the ASCM estimator is:

\[ \hat{Y}_{1t}^{(0),\text{ASCM}} = \underbrace{\sum_{j=2}^{J+1} w_j^* Y_{jt}}_{\text{SCM counterfactual}} + \underbrace{\hat{m}\left(\mathbf{X}_1 - \sum_{j=2}^{J+1} w_j^* \mathbf{X}_j\right)}_{\text{outcome model correction}} \]

where \(\hat{m}(\cdot)\) is an outcome model, typically ridge regression, fitted on the donor pool. The argument to \(\hat{m}\) is the interpolation residual: the difference between the treated unit’s characteristics and the synthetic unit’s characteristics. If the SCM fit is perfect, this residual is zero and the correction vanishes, ASCM reduces to SCM exactly. If the fit is imperfect, the outcome model adjusts the counterfactual in a controlled manner.

The bias-variance tradeoff is central here:

  • Low regularisation: The outcome model can extrapolate aggressively, reducing bias from the convex hull constraint but increasing variance and the risk of overfitting. In the limit, ASCM approaches unconstrained OLS regression.
  • High regularisation: The outcome model shrinks toward zero, providing minimal correction. ASCM approaches standard SCM.
  • Optimal regularisation: Chosen via cross-validation on the pre-treatment period. The augsynth R package automates this.
NoteASCM vs. SDID: addressing different problems

ASCM and SDID both extend classic SCM, but they address different sources of bias through different mechanisms.

ASCM corrects bias through an outcome model (ridge regression on interpolation residuals) applied after the SCM weights are computed. It is designed for the case where the treated unit falls partially outside the convex hull.

SDID corrects bias through time weights and unit fixed effects applied during the weight construction itself. It is designed for settings with multiple treated units, staggered treatment, or the need for asymptotic standard errors.

Confusing the two is a category error. If you have a single treated unit with imperfect pre-treatment fit, use ASCM. If you have multiple treated units and need valid asymptotic inference, use SDID.

Synthetic Difference-in-Differences: Dual Weighting

Synthetic Difference-in-Differences (SDID), introduced by Arkhangelsky, Athey, Hirshberg, Imbens & Wager (2021), is the direct intellectual descendant of both DiD and SCM. It makes two modifications to classic SCM: it adds unit fixed effects (making the estimator invariant to additive unit-level shifts, as DiD does via differencing), and introduces time weights \(\hat{\lambda}_t\) that balance pre- and post-treatment periods to remove bias from atypical time periods.

The estimator solves:

\[ (\hat{\tau}^{sdid}, \hat{\mu}, \hat{\alpha}, \hat{\beta}) = \arg\min_{\tau,\mu,\alpha,\beta} \sum_{i=1}^{N} \sum_{t=1}^{T} \hat{\omega}_i \hat{\lambda}_t \left(Y_{it} - \mu - \alpha_i - \beta_t - W_{it}\tau\right)^2 \tag{6} \]

where \(W_{it}\) is the treatment indicator, the unit weights \(\hat{\omega}_i\) are analogous to SCM weights (optimised for pre-treatment balance, with a regularisation parameter \(\zeta = (N_{tr} T_{post})^{1/4} \hat{\sigma}\) that prevents weight concentration), and the time weights \(\hat{\lambda}_t\) down-weight pre-treatment periods that are atypical relative to the post-treatment average.

NoteDual weighting is not “double robustness”

SDID employs dual weighting over both units and time periods, providing robustness that neither DiD nor SCM alone can offer: it remains consistent when either the DiD parallel trends assumption or the SCM interpolation condition holds. If parallel trends are valid, SDID reduces to something close to DiD with reweighted controls. If SCM’s assumptions hold, SDID reduces to something close to SCM with time adjustments.

However, this is not “double robustness” in the semiparametric sense (where consistency requires correct specification of either the outcome model or the propensity score). SDID’s robustness comes from a different mechanism: weighting on two dimensions. The distinction matters because “double robustness” carries specific theoretical connotations that do not apply here.

Arkhangelsky et al. (2021) prove asymptotic normality of \(\hat{\tau}^{sdid}\), enabling inference via jackknife or bootstrap, unlike classic SCM which must rely on permutation tests with their granularity floor of \(1/(J+1)\).

Structural Comparison

Code
comp <- data.frame(
  Estimator    = c("DiD", "SCM", "ASCM", "SDID"),
  Unit_Weights = c("Uniform (1/N)", "Optimised (convex)", "Optimised + ridge", "Optimised + intercept"),
  Time_Weights = c("Uniform", "Uniform", "Uniform", "Optimised"),
  Unit_FE      = c("Yes", "No", "No", "Yes"),
  Extrapolation= c("Allowed", "Prohibited (convex hull)", "Controlled (ridge penalty)", "Regularised"),
  Inference    = c("Asymptotic (cluster SE)", "Permutation tests", "Conformal inference", "Asymptotic (normality)"),
  When_to_Use  = c(
    "Many units, parallel trends plausible",
    "1 treated unit, excellent pre-fit, long T₀",
    "1 treated unit, imperfect pre-fit",
    "Multiple units/periods, dual weighting desired"
  ),
  check.names = FALSE
)

kable(comp, booktabs = TRUE) |>
  kable_styling(bootstrap_options = c("striped", "hover", "condensed"),
                full_width = TRUE, font_size = 11) |>
  column_spec(1, bold = TRUE)
Structural comparison of causal estimators for aggregate panels. DiD = Difference-in-Differences; SCM = Synthetic Control Method; ASCM = Augmented SCM; SDID = Synthetic Difference-in-Differences; MSPE = Mean Squared Prediction Error; ATT = Average Treatment Effect on the Treated; FE = Fixed Effects.
Estimator Unit_Weights Time_Weights Unit_FE Extrapolation Inference When_to_Use
DiD Uniform (1/N) Uniform Yes Allowed Asymptotic (cluster SE) Many units, parallel trends plausible
SCM Optimised (convex) Uniform No Prohibited (convex hull) Permutation tests 1 treated unit, excellent pre-fit, long T₀
ASCM Optimised + ridge Uniform No Controlled (ridge penalty) Conformal inference 1 treated unit, imperfect pre-fit
SDID Optimised + intercept Optimised Yes Regularised Asymptotic (normality) Multiple units/periods, dual weighting desired

SDID Applied to Proposition 99

Code
if (!requireNamespace("synthdid", quietly = TRUE)) {
  if (!requireNamespace("remotes", quietly = TRUE))
    install.packages("remotes", repos = "https://cloud.r-project.org", quiet = TRUE)
  remotes::install_github("synth-inference/synthdid", quiet = TRUE)
}
library(synthdid)

data("california_prop99")
setup <- panel.matrices(california_prop99)

tau_sdid <- synthdid_estimate(setup$Y, setup$N0, setup$T0)
tau_did  <- did_estimate(setup$Y, setup$N0, setup$T0)
tau_sc   <- sc_estimate(setup$Y, setup$N0, setup$T0)

results_df <- data.frame(
  Method   = c("SDID (Arkhangelsky et al. 2021)", "SCM (Abadie et al. 2010)", "DiD"),
  Estimate = round(c(as.numeric(tau_sdid), as.numeric(tau_sc), as.numeric(tau_did)), 1),
  Note     = c(
    "Preferred when multiple units available, dual weights over units and time",
    "Classic baseline, pure convex interpolation, single treated unit",
    "Overestimates; parallel trends violated (CA declining faster pre-treatment)"
  )
)

kable(results_df, booktabs = TRUE,
      col.names = c("Method", "Estimate (packs per capita)", "Note")) |>
  kable_styling(bootstrap_options = c("striped", "hover"), full_width = TRUE, font_size = 12) |>
  column_spec(3, color = "grey40", italic = TRUE)
Estimated effect of Proposition 99 (reduction in per capita cigarette packs) under three estimators. Note that the three estimates differ not only in magnitude but in their identifying assumptions, comparing them is itself informative about which assumptions bind.
Method Estimate (packs per capita) Note
SDID (Arkhangelsky et al. 2021) -15.6 Preferred when multiple units available, dual weights over units and time
SCM (Abadie et al. 2010) -19.6 Classic baseline, pure convex interpolation, single treated unit
DiD -27.3 Overestimates; parallel trends violated (CA declining faster pre-treatment)
Code
plot(tau_sdid)

SDID visualisation: weighted trajectories and estimated treatment effect for California Proposition 99.

The pattern across estimators is informative. DiD overestimates the effect because California was already declining faster than the average state, its pre-treatment trend is steeper, so a naive before-after comparison attributes too much to the policy. SCM corrects this by matching the pre-treatment trajectory more carefully. SDID lands between the two, adjusting for both unit-level differences (through optimised unit weights) and time-period anomalies (through time weights). The difference between SCM and SDID estimates reflects partly the different treatment of atypical pre-treatment periods and partly the intercept term that SDID adds to the weight construction.

TipWhen to prefer SCM vs. SDID

Use classic SCM when you have one treated unit, a long pre-treatment series, excellent pre-treatment fit, and need the strongest possible transparency about counterfactual construction. The path plot, actual trajectory vs. synthetic trajectory, does the communication work for non-technical audiences.

Use SDID when you have multiple treated units, imperfect pre-treatment fit, or need valid asymptotic standard errors for formal inference. When in doubt, estimate both and compare, a large discrepancy is itself informative about which assumptions are binding.

The tidysynth Workflow

For a more readable, modular pipeline, the tidysynth package (Dunford, 2021) provides a tidyverse-compatible pipe interface over the same SCM algorithm:

Code
library(tidysynth)

smoking |>
  synthetic_control(
    outcome  = cigsale,
    unit     = state,
    time     = year,
    i_unit   = "California",
    i_time   = 1989,
    generate_placebos = TRUE
  ) |>
  generate_predictor(time_window = 1980:1988,
                     lnincome   = mean(lnincome, na.rm = TRUE),
                     retprice   = mean(retprice, na.rm = TRUE),
                     age15to24  = mean(age15to24, na.rm = TRUE)) |>
  generate_predictor(time_window = 1975, cigsale_1975 = mean(cigsale, na.rm = TRUE)) |>
  generate_predictor(time_window = 1980, cigsale_1980 = mean(cigsale, na.rm = TRUE)) |>
  generate_predictor(time_window = 1988, cigsale_1988 = mean(cigsale, na.rm = TRUE)) |>
  generate_weights(optimization_window = 1970:1988) |>
  generate_control() |>
  plot_trends()
TipThe code is the easy part

Running synth() or synthetic_control() takes minutes. The real analysis is in the diagnostics: inspect the path plot, examine weight sparsity, verify covariate balance, run both placebo tests, and check leave-one-out stability. A result that passes all of these is worth presenting to a CFO. One that skips any of them is not.

Diagnostic Checklist for Practitioners

Code
checklist <- data.frame(
  Diagnostic = c(
    "Pre-treatment fit (MSPE_pre)",
    "Covariate balance",
    "Weight sparsity",
    "Convex hull check",
    "In-space placebo test",
    "In-time placebo test",
    "Donor pool robustness (leave-one-out)",
    "SUTVA / spillover check",
    "RMSPE post/pre ratio"
  ),
  What_to_Verify = c(
    "MSPE_pre near 0; synthetic series visually tracks the actual pre-treatment path",
    "tab.pred: small differences between treated and synthetic across all predictors",
    "Few positive weights (interpretability); many near-zero weights is a geometric feature, not a flaw",
    "If treated unit is an extreme outlier in all dimensions, SCM is not applicable, use ASCM",
    "Real gap >> placebo gap distribution; exact permutation p-value providing evidence against the null",
    "Simulate treatment k years early, gap after fictitious treatment date should be near zero",
    "Re-estimate excluding each control with positive weight; check estimate stability",
    "Check for cross-border purchasing, diaspora effects, or industry spillovers",
    "Ratio >> 1 for treated unit; ideally the highest ratio in the full distribution"
  ),
  Red_Flag = c(
    "High MSPE_pre: synthetic does not track pre-trends, SCM not valid, consider ASCM",
    "Large balance differences: treated unit outside convex hull, dangerous extrapolation",
    "All weights near 1/J: synthetic ≈ simple mean, no added value over naive DiD",
    "Treated unit far outside hull in all dimensions, SCM not recommended",
    "Real gap inside placebo cloud, effect indistinguishable from noise",
    "Large gap before fictitious treatment date, pre-existing confounders or anticipation effects",
    "Estimate changes substantially when one control is dropped, fragile result",
    "Contaminated controls, bias (attenuation or amplification) in either direction",
    "Ratio near 1 for treated unit: effect not distinguishable from random variation"
  ),
  check.names = FALSE
)

kable(checklist, booktabs = TRUE) |>
  kable_styling(bootstrap_options = c("striped", "hover", "condensed"),
                full_width = TRUE, font_size = 11) |>
  column_spec(1, bold = TRUE, width = "22%") |>
  column_spec(3, color = "darkred", width = "35%")
Quality and robustness checklist for Synthetic Control applications.
Diagnostic What_to_Verify Red_Flag
Pre-treatment fit (MSPE_pre) MSPE_pre near 0; synthetic series visually tracks the actual pre-treatment path High MSPE_pre: synthetic does not track pre-trends, SCM not valid, consider ASCM
Covariate balance tab.pred: small differences between treated and synthetic across all predictors Large balance differences: treated unit outside convex hull, dangerous extrapolation
Weight sparsity Few positive weights (interpretability); many near-zero weights is a geometric feature, not a flaw All weights near 1/J: synthetic ≈ simple mean, no added value over naive DiD
Convex hull check If treated unit is an extreme outlier in all dimensions, SCM is not applicable, use ASCM Treated unit far outside hull in all dimensions, SCM not recommended
In-space placebo test Real gap >> placebo gap distribution; exact permutation p-value providing evidence against the null Real gap inside placebo cloud, effect indistinguishable from noise
In-time placebo test Simulate treatment k years early, gap after fictitious treatment date should be near zero Large gap before fictitious treatment date, pre-existing confounders or anticipation effects
Donor pool robustness (leave-one-out) Re-estimate excluding each control with positive weight; check estimate stability Estimate changes substantially when one control is dropped, fragile result
SUTVA / spillover check Check for cross-border purchasing, diaspora effects, or industry spillovers Contaminated controls, bias (attenuation or amplification) in either direction
RMSPE post/pre ratio Ratio >> 1 for treated unit; ideally the highest ratio in the full distribution Ratio near 1 for treated unit: effect not distinguishable from random variation

Decision Tree: Which Method to Use?

Code
decision <- data.frame(
  Situation = c(
    "1 treated unit, large T₀, excellent pre-fit",
    "1 treated unit, large T₀, reasonable pre-fit",
    "1 treated unit, small T₀, poor pre-fit",
    "Multiple treated units, large N",
    "Multiple treated units, staggered adoption",
    "Parallel trends uncertain"
  ),
  Method = c(
    "Classic SCM (Abadie et al. 2010)",
    "SCM + placebos + leave-one-out robustness",
    "ASCM (Ben-Michael et al. 2021)",
    "SDID (Arkhangelsky et al. 2021)",
    "SDID with per-cohort estimation",
    "SDID (more robust than DiD or SC in isolation)"
  ),
  Rationale = c(
    "Sparse, interpretable weights; no extrapolation in observed covariate space",
    "Placebos quantify significance; LOO verifies robustness against individual donors",
    "Ridge correction reduces bias from imperfect convex hull interpolation",
    "Valid asymptotic inference in large panels (normality of SDID estimator)",
    "Handles staggered rollout naturally via per-cohort estimation",
    "SDID achieves consistency if either DiD or SC conditions hold (partially)"
  ),
  R_Package = c("Synth / tidysynth", "Synth / tidysynth", "augsynth", "synthdid",
                "synthdid / causalverse", "synthdid"),
  check.names = FALSE
)

kable(decision, booktabs = TRUE) |>
  kable_styling(bootstrap_options = c("striped", "hover"), full_width = TRUE, font_size = 11) |>
  column_spec(1, bold = TRUE, width = "25%") |>
  column_spec(4, color = "#005F73")
Decision tree for selecting the appropriate estimator based on data structure and pre-treatment fit.
Situation Method Rationale R_Package
1 treated unit, large T₀, excellent pre-fit Classic SCM (Abadie et al. 2010) Sparse, interpretable weights; no extrapolation in observed covariate space Synth / tidysynth
1 treated unit, large T₀, reasonable pre-fit SCM + placebos + leave-one-out robustness Placebos quantify significance; LOO verifies robustness against individual donors Synth / tidysynth
1 treated unit, small T₀, poor pre-fit ASCM (Ben-Michael et al. 2021) Ridge correction reduces bias from imperfect convex hull interpolation augsynth
Multiple treated units, large N SDID (Arkhangelsky et al. 2021) Valid asymptotic inference in large panels (normality of SDID estimator) synthdid
Multiple treated units, staggered adoption SDID with per-cohort estimation Handles staggered rollout naturally via per-cohort estimation synthdid / causalverse
Parallel trends uncertain SDID (more robust than DiD or SC in isolation) SDID achieves consistency if either DiD or SC conditions hold (partially) synthdid

Practical Interpretation

The post-treatment gap \(\hat{\alpha}_{1t} = Y_{1t} - \hat{Y}_{1t}^{(0)}\) is the estimated ATT (Average Treatment Effect on the Treated) at each point in time \(t > T_0\). It answers: What is the difference between what happened to the treated unit and what would have happened in the counterfactual world where treatment never occurred?

This gap is the estimated causal effect for a specific unit at a specific time. For the fintech, it is the difference between Portugal’s actual 90-day retention and the retention that a weighted combination of 11 other markets would predict. To translate the raw lift (+34%) into a causal estimate (+5 pp): the raw lift captures the total change in Portugal post-launch; the SCM gap decomposes that change into a counterfactual component (what would have happened anyway, absorbed by the synthetic trajectory) and a causal component (the residual gap). The path from raw headline to causal estimate to revenue impact follows this pipeline: 34% total observed lift → SCM decomposition → ~5 pp causal effect → multiply by per-user LTV and active user base → €2.3M incremental annual revenue.

The practical workflow for decision-makers:

  1. Estimate the causal gap in the primary metric (e.g., retention).
  2. Aggregate across post-treatment periods, compute the average post-treatment ATT, not just the point estimate at one time.
  3. Convert to financial terms, multiply the retention lift by per-user LTV and the affected user base.
  4. Stress-test the result, report the permutation p-value, the leave-one-out range, and the comparison with DiD and SDID estimates.
  5. Present the range, not just the point, the honest answer is “the causal effect is between X and Y, with an exact permutation p-value providing evidence against the null,” not “the effect is exactly Z.”

Conclusion

The Synthetic Control Method constructs an artificial counterfactual by finding an optimally weighted convex combination of untreated units that reproduces the treated unit’s pre-treatment trajectory. It is the right tool precisely when DiD fails: a single treated unit, non-parallel pre-trends, and a long pre-treatment series that can credibly establish the quality of the match.

The method’s key guarantee, the convex hull constraint, ensures that the synthetic control is always a weighted average of real observations in observed covariate space, never an explicitly extrapolated artifact. In practice, this buys you two things that no other method provides at this level of transparency: no invisible extrapolation (every weight is non-negative and corresponds to a real unit), and a directly inspectable pre-treatment fit that serves as the identifying assumption made visible. The identification still relies on the underlying latent factor model, but the pre-treatment fit gives you observable evidence about whether that model holds.

When the pre-treatment fit is imperfect because the treated unit falls outside the convex hull, ASCM (Ben-Michael et al. 2021) adds a ridge-corrected outcome model that relaxes the interpolation constraint in a controlled manner, navigating the bias-variance tradeoff between pure SCM and unconstrained regression. When the data structure includes multiple treated units or the analyst needs valid asymptotic standard errors, SDID (Arkhangelsky et al. 2021) applies dual weighting over both units and time periods, achieving robustness that neither DiD nor SCM can deliver alone.

The choice of estimator should follow from the data structure and pre-treatment fit, not from convenience, software availability, or disciplinary convention. The diagnostics, placebo tests, and sensitivity analyses presented here are not optional appendages; they are the substance of the causal claim.


References

Abadie, A. (2020). Using synthetic controls: Feasibility, data requirements, and methodological aspects. Journal of Economic Literature, 58(2), 391–424. https://doi.org/10.1257/jel.20191450

Abadie, A., & Gardeazabal, J. (2003). The economic costs of conflict: A case study of the Basque Country. American Economic Review, 93(1), 113–132. https://doi.org/10.1257/000282803321455188

Abadie, A., Diamond, A., & Hainmueller, J. (2010). Synthetic control methods for comparative case studies: Estimating the effect of California’s tobacco control program. Journal of the American Statistical Association, 105(490), 493–505. https://doi.org/10.1198/jasa.2009.ap08746

Abadie, A., Diamond, A., & Hainmueller, J. (2011). Synth: An R package for synthetic control methods in comparative case studies. Journal of Statistical Software, 42(13), 1–17. https://doi.org/10.18637/jss.v042.i13

Abadie, A., Diamond, A., & Hainmueller, J. (2015). Comparative politics and the synthetic control method. American Journal of Political Science, 59(2), 495–510. https://doi.org/10.1111/ajps.12116

Arkhangelsky, D., Athey, S., Hirshberg, D. A., Imbens, G. W., & Wager, S. (2021). Synthetic difference in differences. American Economic Review, 111(12), 4088–4118. https://doi.org/10.1257/aer.20190159

Athey, S., & Imbens, G. W. (2017). The state of applied econometrics: Causality and policy evaluation. Journal of Economic Perspectives, 31(2), 3–32. https://doi.org/10.1257/jep.31.2.3

Ben-Michael, E., Feller, A., & Rothstein, J. (2021). The augmented synthetic control method. Journal of the American Statistical Association, 116(536), 1789–1803. https://doi.org/10.1080/01621459.2021.1929245

Cunningham, S. (2021). Causal inference: The mixtape. Yale University Press. https://mixtape.scunning.com

Dunford, E. (2021). tidysynth: A tidy implementation of the synthetic control method [R package]. https://github.com/edunford/tidysynth

Hey Stella. (2024). A practitioner’s guide to weighted synthetic control methods for incrementality testing. https://www.stellaheystella.com/blog/a-practitioners-guide-to-weighted-synthetic-control-methods-for-incrementality-testing

Holland, P. W. (1986). Statistics and causal inference. Journal of the American Statistical Association, 81(396), 945–960.

Huntington-Klein, N. (2021). The effect: An introduction to research design and causality. CRC Press. https://theeffectbook.net

Mendez, C. (2023). Basic synthetic control with R. https://carlos-mendez.org/post/r_basic_synthetic_control/

Mendez, C. (2024). R synthetic control tutorial. https://carlos-mendez.quarto.pub/r-synthetic-control-tutorial/

Nguyen, M. (n.d.). Synthetic difference-in-differences. In A guide on data analysis (Chapter 36). https://mike-data-analysis.share.connect.posit.cloud/sec-synthetic-difference-in-differences.html

Back to top